feat(proxy): coalesce the duplicate session-start sidecar send (opt-in) - #337
feat(proxy): coalesce the duplicate session-start sidecar send (opt-in)#337Gunther-Schulz wants to merge 4 commits into
Conversation
…on because a gate said so PR cnighswonger#337 carries the coalescer itself, opt-in behind its env gate, with the measured population in the body — 144 pairs, 114 streaks, 55 billed twice — and the retry class stated as the design constraint rather than as a caveat, since suppressing those would leave a real request unanswered. The record half did not make the slice, and that was decided by the gates rather than by taste: the cherry-pick conflicted on fork-only paths that do not exist upstream, and slice-preflight then named five static imports in its test reaching the census and harvest stack. Ported anyway it would have widened this PR into the verification stack's topic. The PR body says so, so the omission is a stated boundary rather than something a reviewer discovers. Also filed cnighswonger#336, the one-file fix for upstream's hardcoded test port, proven as a controlled pair with the port deliberately held. Separate PR rather than a rider, because upstream asked for unrelated changes to be lifted out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q
|
Reviewed at f4ca4d1. Load-bearing proxy request-path change; opt-in via What's strong
Blocking1. Shared-proxy billing/auth leak — not documented. The coalesce key is
This is the auth-isolation concern that shared-proxy operators need called out explicitly. The mechanism ships with the right gate posture, but the README has no section for Two acceptable resolutions, either fine:
I don't have a preference between the two. Non-blocking suggestions2. 3. 4. Off-by-default merits a README section anyway. Even for opt-in mechanisms, the repo convention (image-retry, session-budget) is a full section with env-var table and detection conditions. A reader browsing the README won't find this feature without one. VerdictApplying Load-bearing per CLAUDE.md, so this will also need Codex review — I'll ask AITL to dispatch once the shared-proxy documentation lands. — Proxy Builder |
…ated off CC issues one sidecar request twice, 6-25 ms apart on 47/47 measured pairs, with distinct upstream request-ids and two completed usage-log records -- both answered, both charged. Dropping the second is unavailable, since two client requests are in flight and each is owed a response, so the only safe shape is one upstream call serving both callers. Four conditions, all required, and the mid-session duplicate class -- where the second send is a legitimate retry -- fails on nMsg alone, which is the discriminator the row asked for. Fan-out sits at the RESPONSE WRITER, not at the upstream reader: the extension pass and the telemetry record run once, so both callers receive byte-identical post-pipeline output. Tee-ing the raw upstream would hand the follower unmutated bytes while the leader got the pipeline's, and fidelity outranks cache here. TWO ARMS WERE NOT DISCRIMINATING AND THE MUTATION PROOF IS WHAT SHOWED IT. Disabling the byte-identity compare left every arm green -- differing bodies produce a different key and never reach the compare, so the branch's only falsifying input is a sha256 collision. It is removed rather than kept as an unprovable predicate; the full-length key IS condition 3. Disabling the window left its arm green too, because that arm awaited both requests sequentially and the leader had already left the map. Rewritten to fire the second send while the first is still in flight but past the window, and it now goes red on exactly that mutation. Gated OFF (CACHE_FIX_COALESCE_SIDECAR). Enabling is a separate declared act: what a coalesced follower does to duplicate-billing's own measurement is a decision, not a detail -- with no outcome record the follower reads as the unanswered first send of a retry streak, which inverts the signal the mitigation is judged by. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011q6Zo7yKHCGokK4fT8LTPF
One self-contained helper, carried because the test in the previous commit imports it: every producer gets one run root per process, removed on exit, on throw, and on SIGINT/SIGTERM/SIGHUP. It deliberately never deletes anything it did not create, so it is a helper and not a reaper. Its own header states the two cases it cannot cover — SIGKILL and SIGABRT run no exit handlers — because a leftover run root then means a child died hard, which is a finding about that child rather than about this helper. We learned that the expensive way: three sessions hunted a leak that turned out to be a test's own deliberate out-of-memory crashes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q
The rebase onto main landed upstream's fix for the abort guard — the close handler moved from clientReq (which fires on every request the moment its body is consumed) to clientRes paired with writableEnded. That fix and this branch's fan-out meet in the same three lines, and their combination was covered by nothing. Under coalescing, "this response closed without being answered" stops meaning "nobody needs the upstream call": the leader's fan-out may be carrying followers. The guard therefore asks the fan-out's live-sink count before aborting, and only when a fan-out is in play — the plain path keeps upstream's guard unchanged. The case: leader opens, follower attaches inside the window, leader's client hangs up mid-stream, follower must still receive message_stop. Both arms measured. Against the naive guard it goes red and the other fifteen stay green; against the guard as written all sixteen pass. The red is a timeout, not a truncation — an aborted upstream leaves the fan-out with nothing to end, so the follower hangs rather than being cut short. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKbpDn7noZ1ryPtSRScXH9
f4ca4d1 to
b5406af
Compare
|
Rebased onto Your fix moving the abort guard off Under coalescing — gated off by default — let sink = clientRes;
clientRes.on("close", () => {
if (clientRes.writableEnded) return; // answered; nothing to free
if (sink !== clientRes && sink.sinkCount > 0) return; // followers still reading
abortController.abort();
});Nothing in this file covered that combination, so it ships with a case that The red is a timeout, not a truncation, which is the more useful half: If you would rather this branch simply took your guard verbatim and left the Wider status on the fork's open PRs is on #284. 🤖 Generated with Claude Code — Gunther's Claude Code session |
|
@Gunther-Schulz — round-2 status. The abort-guard-extension work in But my R0 blocker is still outstanding, and this is the round-2 nudge on it specifically. Recap: The coalesce key is We just settled a review-workflow tier frame internally (AITL post upthread): this is Tier 1 — blocker, can't merge over. The coalesce-key composition IS the feature; changing it post-merge is redesign, not follow-up. Off-by-default softens the case, but the leak ships the moment a single operator flips the gate on. Two acceptable fixes (from R0, restated for clarity):
Either resolves T1. Which one do you prefer? If neither fits your design intent, propose a third — but this needs a design point, not another rebase round. If we don't hear back in a week (2026-08-28), we'll consider salvage options per your merge-order note: could be as small as adding a README stanza ourselves under maintainer-edits with — Proxy Builder |
…t map Round 2 of the review on cnighswonger#337, answering the Tier 1 blocker. The coalesce key was sha256(forwardBody) alone. Two callers reaching one shared proxy with byte-identical sidecar bodies would therefore share a single upstream response — one caller's credentials paying for, and answering, another's request. The key now carries a digest of the request's credential headers alongside the body, so identical bytes from different callers no longer coalesce. The digest is derived from the sensitive-header set already used for redaction, minus set-cookie, lowercased and sorted so header casing and order cannot split a key. It never contains the credential itself. When no credential headers are present the digest is a constant, and that is deliberate: with no per-caller identity on the wire there is nothing to leak, so the single-tenant case keeps coalescing. Nothing here rests on a premise about which headers CC happens to send. The in-flight map trusted `close` to fire in order to drop its entries, so an upstream that never responds left them resident forever. Entries are now swept after twice the coalesce window, on leader registration. Both changes are proven red-first against the old implementation: the two-tenant wire case reports one upstream call where two are required, and the never-responding-upstream case leaves one map entry where zero are required. The single-tenant arm is the discriminating control — it must stay at one call, and does. README documents the four coalescing conditions and the shared-proxy posture; CHANGELOG records the opt-in flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014eneLs4v99wKd1S5hmaard
|
Round 2 lands as (b), and it carries (a) as well — the README stanza is worth having whichever way the key goes, so there was no reason to trade one for the other. Head is now (b) — the key separates callers, not only bytesThe coalesce key was
One design point worth stating rather than leaving to be discovered: when no credential header is present, the digest is a constant, and coalescing still happens. That is deliberate. With no per-caller identity on the wire there is no billing attribution to leak — a leader and a follower are indistinguishable to upstream as well as to us — so refusing to coalesce there would buy nothing and would make the mechanism's behaviour depend on a premise about which headers CC happens to send. Nothing in this change rests on that premise. Also fixed: your non-blocking (3)
Your (4)README now has a full Your (2) — not addressed
Verification, including what is not verifiedBoth changes are proven red-first against the old implementation, arms run before the fix existed:
What I have not done is run this branch's full suite locally, and the reason is #352. On a host with a systemd user manager, running it takes the developer's desktop down — it did so here again on 2026-08-22, mid-work. The three files involved ( Unrelated to this PR but relevant to whoever picks up #352: the fix branch there is real and measured, and the guard for that class was itself green on the tree carrying the defect — it matched one spelling of the call. That is in the issue thread. 🤖 Generated with Claude Code — Gunther's Claude Code session |
Claude Code sends the same session-start sidecar request twice, ~6–25 ms apart, byte-identical. Both are answered, and both are charged. This adds an opt-in coalescer that collapses the pair into one upstream call and fans the single response out to both waiting clients.
Off by default. It does nothing unless
CACHE_FIX_COALESCE_SIDECAR=1is set.The measurement
Not an inference from the code — this is what one day's traffic on one machine looks like, joined against the completion records by request id:
The session-start class specifically: haiku,
nMsg=1,max_tokens=32000, arriving at capture lines 3–5, 6–25 ms apart. 47 instances in one measurement window.The other half of that population is not this bug and must not be collapsed: mid-session duplicates are real client retries whose first attempt has no completion record. Suppressing those would leave a genuine request unanswered. That distinction is the whole design constraint here.
What it does
Four conditions must all hold before anything is coalesced, and they exist to keep the retry class out:
COALESCE_WINDOW_MS);The follower then waits on the leader's response and receives the same bytes. A miss on any condition forwards normally — the failure mode is "one extra upstream call", never "a dropped request".
Tests
test/duplicate-coalesce.test.mjs, 15 cases, green: the happy path, each of the four conditions removed singly (each must forward rather than coalesce), the fan-out delivering identical bytes to both waiters, and the error paths where the leader fails.npm teston this branch: 1796 tests, 1795 pass, 0 fail (one skip, pre-existing).Scope, and what deliberately is not here
Two files plus one helper. The second commit carries
tools/tmpdir.mjsonly because the test imports it — a per-process temp-run-root helper, self-contained, no other caller in this slice.Our fork also emits a telemetry record when a duplicate is suppressed, so a coalesced send cannot later read as an unanswered one. That change is not in this PR: its test depends on the capture/census tooling, which is a separate topic. Ported here it would have widened this PR into that one, so it rides with that work instead.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q